page.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510
  1. "use client";
  2. import { useEffect, useState, useCallback } from "react";
  3. import { useParams, useRouter } from "next/navigation";
  4. import { useI18n } from "@/lib/i18n/provider";
  5. import { ResponsiveLayout } from "@/components/layout";
  6. import { Button } from "@/components/ui/button";
  7. import { Card } from "@/components/ui/card";
  8. import { Input } from "@/components/ui/input";
  9. import { Textarea } from "@/components/ui/textarea";
  10. import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
  11. import { Badge } from "@/components/ui/badge";
  12. import { toast } from "@/hooks/useToast";
  13. import {
  14. fetchDocument,
  15. type Document,
  16. } from "@/features/agent/services/documentApi";
  17. import {
  18. fetchChunks,
  19. executeChunking,
  20. updateChunk,
  21. deleteChunks,
  22. type DocumentChunk,
  23. } from "@/features/agent/services/chunkApi";
  24. import {
  25. ChevronLeft,
  26. ChevronRight,
  27. Scissors,
  28. Pencil,
  29. Trash2,
  30. Loader2,
  31. FileText,
  32. CheckCircle2,
  33. Clock,
  34. XCircle,
  35. } from "lucide-react";
  36. type ChunkMethod = "char_count" | "separator";
  37. interface DocumentDetailPageProps {
  38. embedded?: boolean;
  39. docId?: number;
  40. onBack?: () => void;
  41. }
  42. export default function DocumentDetailPage(props: DocumentDetailPageProps = {}) {
  43. const { embedded = false, docId: propDocId, onBack } = props;
  44. const params = useParams<{ docId?: string }>();
  45. const router = useRouter();
  46. useI18n();
  47. const docIdNum = propDocId ?? Number(params.docId ?? 0);
  48. const [doc, setDoc] = useState<Document | null>(null);
  49. const [loadingDoc, setLoadingDoc] = useState(true);
  50. const [docError, setDocError] = useState("");
  51. const [chunks, setChunks] = useState<DocumentChunk[]>([]);
  52. const [loadingChunks, setLoadingChunks] = useState(false);
  53. const [chunkPage, setChunkPage] = useState(1);
  54. const [chunkTotalPage, setChunkTotalPage] = useState(1);
  55. const [chunkTotal, setChunkTotal] = useState(0);
  56. const pageSize = 10;
  57. const [method, setMethod] = useState<ChunkMethod>("char_count");
  58. const [chunkSize, setChunkSize] = useState(500);
  59. const [separator, setSeparator] = useState("");
  60. const [executing, setExecuting] = useState(false);
  61. const [editChunk, setEditChunk] = useState<DocumentChunk | null>(null);
  62. const [editContent, setEditContent] = useState("");
  63. const [saving, setSaving] = useState(false);
  64. const [showConfirm, setShowConfirm] = useState(false);
  65. const loadDoc = useCallback(async () => {
  66. if (!docIdNum || isNaN(docIdNum)) {
  67. setDocError("文档 ID 无效");
  68. setLoadingDoc(false);
  69. return;
  70. }
  71. try {
  72. setLoadingDoc(true);
  73. const d = await fetchDocument(docIdNum);
  74. setDoc(d);
  75. setDocError("");
  76. } catch (e: unknown) {
  77. const msg = e instanceof Error ? e.message : "加载文档失败";
  78. setDocError(msg);
  79. } finally {
  80. setLoadingDoc(false);
  81. }
  82. }, [docIdNum]);
  83. const loadChunks = useCallback(async (page: number = 1) => {
  84. if (!docIdNum || isNaN(docIdNum)) return;
  85. try {
  86. setLoadingChunks(true);
  87. const res = await fetchChunks(docIdNum, page, pageSize);
  88. setChunks(res.chunks || []);
  89. setChunkTotalPage(res.total_page || 1);
  90. setChunkTotal(res.total || 0);
  91. } catch {
  92. setChunks([]);
  93. } finally {
  94. setLoadingChunks(false);
  95. }
  96. }, [docIdNum, pageSize]);
  97. useEffect(() => {
  98. loadDoc();
  99. loadChunks(chunkPage);
  100. }, [loadDoc, loadChunks, chunkPage]);
  101. useEffect(() => {
  102. const hasPending = chunks.some(
  103. (c) => c.embedding_status === "pending" || c.embedding_status === "processing"
  104. );
  105. if (!hasPending) return;
  106. const timer = setInterval(() => loadChunks(chunkPage), 2500);
  107. return () => clearInterval(timer);
  108. }, [chunks, loadChunks, chunkPage]);
  109. const handleExecute = async () => {
  110. if (method === "separator" && !separator) {
  111. toast.error("请输入分隔符");
  112. return;
  113. }
  114. try {
  115. setExecuting(true);
  116. const res = await executeChunking(docIdNum, {
  117. method,
  118. chunk_size: method === "char_count" ? chunkSize : undefined,
  119. separator: method === "separator" ? separator : undefined,
  120. });
  121. setChunks(res.chunks || []);
  122. setChunkPage(1);
  123. toast.success(`分段完成,共 ${res.chunk_count} 段`);
  124. } catch (e: unknown) {
  125. const msg = e instanceof Error ? e.message : "分段失败";
  126. toast.error(msg);
  127. } finally {
  128. setExecuting(false);
  129. }
  130. };
  131. const handleDelete = async () => {
  132. try {
  133. await deleteChunks(docIdNum);
  134. setChunks([]);
  135. setChunkPage(1);
  136. setChunkTotalPage(1);
  137. setChunkTotal(0);
  138. setShowConfirm(false);
  139. toast.success("分段已删除");
  140. } catch (e: unknown) {
  141. const msg = e instanceof Error ? e.message : "删除失败";
  142. toast.error(msg);
  143. }
  144. };
  145. const openEdit = (chunk: DocumentChunk) => {
  146. setEditChunk(chunk);
  147. setEditContent(chunk.content);
  148. };
  149. const handleSaveEdit = async () => {
  150. if (!editChunk || !editContent.trim()) return;
  151. try {
  152. setSaving(true);
  153. await updateChunk(docIdNum, editChunk.id, editContent);
  154. toast.success("分段已更新,正在重新向量化");
  155. setEditChunk(null);
  156. loadChunks();
  157. } catch (e: unknown) {
  158. const msg = e instanceof Error ? e.message : "保存失败";
  159. toast.error(msg);
  160. } finally {
  161. setSaving(false);
  162. }
  163. };
  164. const statusBadge = (status: string) => {
  165. switch (status) {
  166. case "completed":
  167. return (
  168. <Badge className="bg-green-100 text-green-700 border-green-200">
  169. <CheckCircle2 className="w-3 h-3 mr-1" />
  170. 已向量化
  171. </Badge>
  172. );
  173. case "processing":
  174. return (
  175. <Badge className="bg-blue-100 text-blue-700 border-blue-200">
  176. <Loader2 className="w-3 h-3 mr-1 animate-spin" />
  177. 向量化中
  178. </Badge>
  179. );
  180. case "pending":
  181. return (
  182. <Badge className="bg-yellow-100 text-yellow-700 border-yellow-200">
  183. <Clock className="w-3 h-3 mr-1" />
  184. 待向量化
  185. </Badge>
  186. );
  187. case "failed":
  188. return (
  189. <Badge className="bg-red-100 text-red-700 border-red-200">
  190. <XCircle className="w-3 h-3 mr-1" />
  191. 向量化失败
  192. </Badge>
  193. );
  194. default:
  195. return <Badge>{status}</Badge>;
  196. }
  197. };
  198. const truncate = (text: string, maxLen: number) => {
  199. if (text.length <= maxLen) return text;
  200. return text.slice(0, maxLen) + "...";
  201. };
  202. const headerContent = (
  203. <div className="flex items-center gap-3">
  204. <Button
  205. variant="ghost"
  206. size="icon"
  207. onClick={() => (onBack ? onBack() : router.back())}
  208. className="shrink-0"
  209. >
  210. <ChevronLeft className="w-5 h-5" />
  211. </Button>
  212. <div>
  213. <h1 className="text-lg font-semibold">
  214. {loadingDoc ? "加载中..." : doc?.title || "文档详情"}
  215. </h1>
  216. {doc && (
  217. <div className="flex items-center gap-2 mt-1 text-sm text-muted-foreground">
  218. <Badge variant="secondary">{doc.type}</Badge>
  219. <Badge
  220. className={
  221. doc.status === "published"
  222. ? "bg-green-100 text-green-700"
  223. : "bg-gray-100 text-gray-700"
  224. }
  225. >
  226. {doc.status === "published" ? "已发布" : "草稿"}
  227. </Badge>
  228. <span>·</span>
  229. <span>
  230. {chunkTotal > 0 ? `${chunkTotal} 个分段` : "未分段"}
  231. </span>
  232. </div>
  233. )}
  234. </div>
  235. </div>
  236. );
  237. const chunkControls = (
  238. <Card className="p-4">
  239. <h2 className="font-medium mb-3 flex items-center gap-2">
  240. <Scissors className="w-4 h-4" />
  241. 分段操作
  242. </h2>
  243. <div className="flex gap-2 mb-3">
  244. <Button
  245. variant={method === "char_count" ? "default" : "outline"}
  246. size="sm"
  247. onClick={() => setMethod("char_count")}
  248. >
  249. 按字数
  250. </Button>
  251. <Button
  252. variant={method === "separator" ? "default" : "outline"}
  253. size="sm"
  254. onClick={() => setMethod("separator")}
  255. >
  256. 按分隔符
  257. </Button>
  258. </div>
  259. <div className="flex items-end gap-3">
  260. {method === "char_count" ? (
  261. <div className="flex-1 max-w-[200px]">
  262. <label className="text-sm text-muted-foreground mb-1 block">
  263. 每段字数
  264. </label>
  265. <Input
  266. type="number"
  267. min={100}
  268. max={10000}
  269. value={chunkSize}
  270. onChange={(e) => setChunkSize(Number(e.target.value) || 500)}
  271. />
  272. </div>
  273. ) : (
  274. <div className="flex-1 max-w-[300px]">
  275. <label className="text-sm text-muted-foreground mb-1 block">
  276. 分隔字符
  277. </label>
  278. <Input
  279. placeholder='如:## 或 \n\n'
  280. value={separator}
  281. onChange={(e) => setSeparator(e.target.value)}
  282. />
  283. </div>
  284. )}
  285. <Button onClick={handleExecute} disabled={executing}>
  286. {executing && <Loader2 className="w-4 h-4 mr-1 animate-spin" />}
  287. 执行分段
  288. </Button>
  289. {chunks.length > 0 && (
  290. <Button
  291. variant="outline"
  292. className="text-red-600"
  293. onClick={() => setShowConfirm(true)}
  294. >
  295. <Trash2 className="w-4 h-4 mr-1" />
  296. 清除分段
  297. </Button>
  298. )}
  299. </div>
  300. {chunks.length > 0 && (
  301. <p className="text-xs text-muted-foreground mt-2">
  302. 执行新分段将自动替换旧分段并重新向量化
  303. </p>
  304. )}
  305. </Card>
  306. );
  307. const chunkList = (
  308. <div className="space-y-3">
  309. <h2 className="font-medium flex items-center gap-2">
  310. <FileText className="w-4 h-4" />
  311. 分段列表
  312. {loadingChunks && <Loader2 className="w-3 h-3 animate-spin" />}
  313. </h2>
  314. {chunks.length === 0 && !loadingChunks ? (
  315. <Card className="p-8 text-center text-muted-foreground">
  316. <Scissors className="w-8 h-8 mx-auto mb-2 opacity-50" />
  317. <p>尚未分段</p>
  318. <p className="text-sm mt-1">
  319. 选择分段方式并执行,系统将自动为每段生成向量索引
  320. </p>
  321. </Card>
  322. ) : (
  323. <>
  324. {chunks.map((chunk) => (
  325. <Card key={chunk.id} className="p-4">
  326. <div className="flex items-start justify-between gap-3">
  327. <div className="flex-1 min-w-0">
  328. <div className="flex items-center gap-2 mb-2">
  329. <span className="text-sm font-medium text-muted-foreground">
  330. 第 {chunk.chunk_index + 1} 段
  331. </span>
  332. {statusBadge(chunk.embedding_status)}
  333. <span className="text-xs text-muted-foreground">
  334. {chunk.content.length} 字
  335. </span>
  336. </div>
  337. <p className="text-sm whitespace-pre-wrap line-clamp-3">
  338. {truncate(chunk.content, 200)}
  339. </p>
  340. </div>
  341. <Button
  342. variant="ghost"
  343. size="icon"
  344. onClick={() => openEdit(chunk)}
  345. className="shrink-0"
  346. >
  347. <Pencil className="w-4 h-4" />
  348. </Button>
  349. </div>
  350. </Card>
  351. ))}
  352. {chunkTotalPage > 1 && (
  353. <div className="flex items-center justify-center gap-2 pt-2">
  354. <Button
  355. variant="outline"
  356. size="sm"
  357. onClick={() => setChunkPage((p) => Math.max(1, p - 1))}
  358. disabled={chunkPage <= 1}
  359. >
  360. <ChevronLeft className="w-4 h-4" />
  361. </Button>
  362. <span className="text-sm text-muted-foreground">
  363. 第 {chunkPage}/{chunkTotalPage} 页,共 {chunkTotal} 条
  364. </span>
  365. <Button
  366. variant="outline"
  367. size="sm"
  368. onClick={() => setChunkPage((p) => Math.min(chunkTotalPage, p + 1))}
  369. disabled={chunkPage >= chunkTotalPage}
  370. >
  371. <ChevronRight className="w-4 h-4" />
  372. </Button>
  373. </div>
  374. )}
  375. </>
  376. )}
  377. </div>
  378. );
  379. const mainContent = (
  380. <div className="max-w-3xl mx-auto space-y-6 p-4">
  381. {docError ? (
  382. <Card className="p-8 text-center text-red-500">
  383. <p>{docError}</p>
  384. <Button variant="outline" className="mt-4" onClick={() => (onBack ? onBack() : router.back())}>
  385. 返回
  386. </Button>
  387. </Card>
  388. ) : loadingDoc ? (
  389. <div className="flex justify-center py-16">
  390. <Loader2 className="w-6 h-6 animate-spin" />
  391. </div>
  392. ) : doc ? (
  393. <>
  394. {chunkControls}
  395. {chunkList}
  396. </>
  397. ) : null}
  398. </div>
  399. );
  400. const editDialog = (
  401. <Dialog
  402. open={!!editChunk}
  403. onOpenChange={(open) => {
  404. if (!open) setEditChunk(null);
  405. }}
  406. >
  407. <DialogContent className="max-w-2xl">
  408. <DialogHeader>
  409. <DialogTitle>
  410. 编辑第 {(editChunk?.chunk_index ?? 0) + 1} 段
  411. </DialogTitle>
  412. <DialogDescription>
  413. 修改后会自动重新向量化该分段
  414. </DialogDescription>
  415. </DialogHeader>
  416. <Textarea
  417. value={editContent}
  418. onChange={(e) => setEditContent(e.target.value)}
  419. rows={12}
  420. className="min-h-[200px]"
  421. />
  422. <div className="flex justify-end gap-2">
  423. <Button variant="outline" onClick={() => setEditChunk(null)}>
  424. 取消
  425. </Button>
  426. <Button onClick={handleSaveEdit} disabled={saving}>
  427. {saving && <Loader2 className="w-4 h-4 mr-1 animate-spin" />}
  428. 保存
  429. </Button>
  430. </div>
  431. </DialogContent>
  432. </Dialog>
  433. );
  434. const confirmDialog = (
  435. <Dialog open={showConfirm} onOpenChange={setShowConfirm}>
  436. <DialogContent>
  437. <DialogHeader>
  438. <DialogTitle>确认清除分段</DialogTitle>
  439. <DialogDescription>
  440. 将删除该文档的所有分段及对应向量索引。此操作不可撤销。
  441. </DialogDescription>
  442. </DialogHeader>
  443. <div className="flex justify-end gap-2">
  444. <Button variant="outline" onClick={() => setShowConfirm(false)}>
  445. 取消
  446. </Button>
  447. <Button variant="destructive" onClick={handleDelete}>
  448. 确认清除
  449. </Button>
  450. </div>
  451. </DialogContent>
  452. </Dialog>
  453. );
  454. if (embedded) {
  455. return (
  456. <>
  457. <div className="flex-1 flex flex-col min-h-0 overflow-hidden">
  458. <div className="px-4 pt-2 pb-1 border-b bg-background">
  459. {headerContent}
  460. </div>
  461. <div className="flex-1 overflow-y-auto">
  462. {mainContent}
  463. </div>
  464. </div>
  465. {editDialog}
  466. {confirmDialog}
  467. </>
  468. );
  469. }
  470. return (
  471. <>
  472. <ResponsiveLayout
  473. main={mainContent}
  474. header={headerContent}
  475. />
  476. {editDialog}
  477. {confirmDialog}
  478. </>
  479. );
  480. }